iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 15
3
Modern Web

從比入門再往前一點開始,一直到深入React.js系列 第 15

【Day.15】React入門 - 非控制組件與useRef

  • 分享至 

  • xImage
  •  

(2024/04/06更新) 因應React在18後更新了許多不同的語法,更新後的教學之後將陸續放在 新的blog 中,歡迎讀者到該處閱讀,我依然會回覆這邊的提問


reference,中文翻譯是「參考」。聽起來好像有點奇怪,但他在程式中一般是指「變數指向的記憶體位置上對應到的值」。

超級複雜的啦。

簡單來說可以想像成是房子跟地址的關係。記憶體就像是地址,變數對應的值就像是房子,「沿著地址找到房子」這個過程就是reference。房子本身可能會有很多內部變動,但不管怎麼變,房子所在的地址是不變的。

在Javascript變數中,物件和Array一般會是以類似reference的方式來傳遞,其他的變數通常會複製一份後,把複製出的那一份拿來傳遞。

更正確的原理可以參考Huli大大的文章:

reference和非控制組件的關係

當程式大起來,網頁中的元素很多,當想要用原始DOM api去操作元素時,卻還要用document.querySelector或是document.getElementById去整個網頁找,就顯得很不直覺。

能不能直接在JSX中取得元素的reference,直接操作元素本身呢 ?

也就是說,理想上我們希望做這種事情

用一個變數去綁在元素的props上,然後就能讓該變數等於綁定元素的reference

大概是這樣(實際上當然不能直接這樣做):

import React, {useRef} from "react";

const InputForm=()=>{
    let accountRef = {};
    let passwordRef = {};

    let refArr = [accountRef,passwordRef];

    return (
        <>
            <input 
                type="text" 
                name="account"
                ref={accountRef} 
            />
            <input 
                type="text" 
                name="password"
                ref={passwordRef} 
            />
            <button onClick={()=>{
                refArr.forEach((item)=>{
                    console.log(item.name+" is "+item.value);
                })
            }}>提交</button>
        </>
    )
}  
export default InputForm;    

過去,React在class component中的確有提供React.createRef()這個API來創造一個可以讓你綁在ref這個props上的object變數。讓你能直接拿到該元素本身、直接用原始DOM方式操作元素。

但是這個API如果直接拿到function component來用會有問題。原因是React.createRef();通常只會在class component的建構子呼叫一次,這樣就能確保這個創造出來的reference指向的是同一個地址。然而function component沒有建構子,每次都一定會重新呼叫function component的定義域,這樣等於每次都會重新創造一次這個object變數,賦予值被重新初始化,指向的reference也會不一樣了

為了解決這個問題,React提供了另一個React hook - useRef

useRef

useRef是一個函式,跟useState一樣接收一個參數,作為變數初始值。差別是useRef回傳的是一個物件,裡面只有一個屬性current:

const data = useRef("初始資料")
console.log(data)

// { current: "初始資料" }

React會確保useRef回傳出來的這個物件不會因為React元件更新而被重新創造。也就是說在你初始化過後,這個物件會始終指向同一個reference。

請注意雖然物件本身指向位置一樣,但如果你重新assign物件中current屬性裡面的值,那current對應的value指向的東西就會不一樣。

也就是說剛剛的「理想」只要引入useRef後,只要先創造要綁在input的propsref上的變數,綁定之後,變數名稱.current就會是該input元素本身,我們就能用直覺的方式操作DOM元素了!

// 引入useRef
import React, {useRef} from "react";

const InputForm=()=>{
    // 建立用來綁定input的變數
    const accountRef = useRef(undefined);
    const passwordRef = useRef(undefined);

    // 為了方便操作,建立一個array來管理這些ref
    const refArr = useRef([accountRef,passwordRef]);

    return ( // 將剛剛創立的變數綁在對應的位置
        <>
            <input 
                type="text" 
                name="account"
                ref={accountRef} 
            />
            <input 
                type="text" 
                name="password"
                ref={passwordRef} 
            />
            <button onClick={()=>{
                refArr.current.forEach((item)=>{
                    console.log(item.current.name+" is "+item.current.value);
                })
            }}>提交</button>
        </>
    )
}
export default InputForm;

useRef的應用

由於useRef「不會因為update元件而被改變reference」的特性,讓其常被用在這些地方:

  • 以原生方式操作DOM元素

    上面講過了

  • counter變數

    如果用一般變數來當counter,元件被update的時候又會被重新初始化,就無法達到計數的效果。

  • addEventListener(removeEventListenser)、setTimeout(clearITimeout)、setInterval(clearInterval)。可以參考我去年的範例 【React.js入門 - 23】 元件練習(下) - 在function利用useEffect遞迴+useState實作動畫

    因為要reference一樣才能正常移除函式,但這件事在callback函式不需要和state/props有關時也可以用useCallback做(後面會講這個是啥)。雖然沒有特別規定,不過有人會認為useCallback在閱讀時會更直覺聯想到是函式。但是如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。

  • 避免useEffect在建立元素時被執行
    也就是某些情況下,因為只會希望元件update時才有side Effect,所以需要一個變數來記憶是否為第一次渲染。
    const mounted=useRef(false);
    useEffect(()=>{
      if(mounted.current===false){
        mounted.current=true;
        /* 下面是 第一次渲染後 */
    
    
        /* 上面是 第一次渲染後 */      
      }
      else{
        /* 下面是元件更新後 */
    
    
        /* 上面是元件更新後 */

      }
      
      return (()=>{
           /* 下面是元件移除前 */
      
      
          /* 上面是元件移除前 */
      })
    },[dependencies參數]);

上一篇
【Day.14】React入門 - 輸入元素與控制組件
下一篇
【Day.16】React入門 - 想要分頁? react-router-dom
系列文
從比入門再往前一點開始,一直到深入React.js30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
wrxue
iT邦好手 1 級 ‧ 2021-11-10 09:04:54

如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。

請問為什麼當callback函式需要和state/props有關時不能使用useCallback呢?

看更多先前的回應...收起先前的回應...
Andy Chang iT邦研究生 4 級 ‧ 2021-11-10 20:33:02 檢舉

Hi,這是因為removeEventListener是透過傳入的函式本身的reference去清除callback。而useCallback在傳入其第二個dep參數內的state/props有變動時會重新定義函式。我們來看一個比較極端的例子:

import { 
    useState,
    useCallback,
    useEffect,
    useRef 
} from "react";

export default function App() {
  const ref = useRef(undefined);
  const [ctn, setCtn] = useState(0);
  
  const handleWindowResize = useCallback(() => {
    // 為了觀察,故意用不好的寫法
    setCtn(ctn+1);
  }, [ctn]);

  useEffect(()=>{
    // 為了觀察,故意違反嚴格模式
    ref.current = handleWindowResize;
    window.addEventListener("resize", handleWindowResize);
  },[])

  useEffect(() => {
    // 為了觀察,故意違反嚴格模式
    console.log(ref.current === handleWindowResize);
  }, [ctn]);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <button>Start editing to see some magic happen!</button>
    </div>
  );
}

實際執行拉動螢幕,你會發現React印出了兩個值:

true
false

第一個true是在第一次渲染畫面後執行,所以印出這個值很正常。但是第二次為什麼印出false呢?這是因為在第二次渲染畫面前,useCallback發現其dep的ctn被改變,所以handleWindowResize被重新定義、reference和addEventListener時不一樣了。

另外一件奇怪的事情是不論你怎麼拉動螢幕,React都只印了兩次結果。這是因為我們起始傳入addEventListener的函式是把ctn從他原本的0變成1。當ctn被改變時,重新定義過的handleWindowResize並沒有被傳入addEventListener。在舊的handleWindowResize定義下,React只是不斷的讓ctn變成「初始的ctn值加1」。這導致ctn在1之後就不再被改變過,useEffect不再觸發。

上面這個例子告訴我們,當在useCallback中使用state時,我們會無法使用removeEventListener清除該函式。這會導致memory leak或是非預期的錯誤。當然,你也可以違反嚴格模式,不在useCallback的dep中加入使用到的state。只是如此一來,在useCallback中定義的函式只會使用到state在建立元件時的初始值,不會隨著該state變動而改變定義內容,這樣就不需要使用state了。

wrxue iT邦好手 1 級 ‧ 2021-11-11 08:09:36 檢舉

請問您這裡所說的 callback函式,是指會使用addEventListener去指定的函式嗎?

我在此系列第20天有看到有用到state的useCallback,不一樣的是直接放在onClick中

    const handleClick = useCallback(() => {
        console.log("isOpen is " + isOpen);
    },[isOpen]);
Andy Chang iT邦研究生 4 級 ‧ 2021-11-11 19:53:30 檢舉

是。在這篇中也是一樣的意思。所以這段話是針對removeEventListenserclearITimeoutclearInterval去註解可能遇到的狀況

  • addEventListener(removeEventListenser)、setTimeout(clearITimeout)、setInterval(clearInterval)

因為要reference一樣才能正常移除函式,但這件事在callback函式不需要和state/props有關時也可以用useCallback做(後面會講這個是啥)。雖然沒有特別規定,不過有人會認為useCallback在閱讀時會更直覺聯想到是函式。但是如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。

至於JSX/React element的onClick,它是React提供的合成事件,我們不需要去清除,自然也不會有上述的問題。可以透過useCallback去定義有state的函式、避免不必要的渲染,沒問題的。

wrxue iT邦好手 1 級 ‧ 2021-11-11 20:55:46 檢舉

大概理解囉,感謝您,您的文章真的很好~

我要留言

立即登入留言